// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

part of google_maps_flutter_web;

// Default values for when the gmaps objects return null/undefined values.
final gmaps.LatLng _nullGmapsLatLng = gmaps.LatLng(0, 0);
final gmaps.LatLngBounds _nullGmapsLatLngBounds =
    gmaps.LatLngBounds(_nullGmapsLatLng, _nullGmapsLatLng);

// Converts a [Color] into a valid CSS value #RRGGBB.
String _getCssColor(Color color) {
  return '#${color.value.toRadixString(16).padLeft(8, '0').substring(2)}';
}

// Extracts the opacity from a [Color].
double _getCssOpacity(Color color) {
  return color.opacity;
}

// Converts options from the plugin into gmaps.MapOptions that can be used by the JS SDK.
// The following options are not handled here, for various reasons:
// The following are not available in web, because the map doesn't rotate there:
//   compassEnabled
//   rotateGesturesEnabled
//   tiltGesturesEnabled
// mapToolbarEnabled is unused in web, there's no "map toolbar"
// myLocationButtonEnabled Widget not available in web yet, it needs to be built on top of the maps widget
//   See: https://developers.google.com/maps/documentation/javascript/examples/control-custom
// myLocationEnabled needs to be built through dart:html navigator.geolocation
//   See: https://api.dart.dev/stable/2.8.4/dart-html/Geolocation-class.html
// trafficEnabled is handled when creating the GMap object, since it needs to be added as a layer.
// trackCameraPosition is just a boolean value that indicates if the map has an onCameraMove handler.
// indoorViewEnabled seems to not have an equivalent in web
// buildingsEnabled seems to not have an equivalent in web
// padding seems to behave differently in web than mobile. You can't move UI elements in web.
gmaps.MapOptions _configurationAndStyleToGmapsOptions(
    MapConfiguration configuration, List<gmaps.MapTypeStyle> styles) {
  final gmaps.MapOptions options = gmaps.MapOptions();

  if (configuration.mapType != null) {
    options.mapTypeId = _gmapTypeIDForPluginType(configuration.mapType!);
  }

  final MinMaxZoomPreference? zoomPreference =
      configuration.minMaxZoomPreference;
  if (zoomPreference != null) {
    options
      ..minZoom = zoomPreference.minZoom
      ..maxZoom = zoomPreference.maxZoom;
  }

  if (configuration.cameraTargetBounds != null) {
    // Needs gmaps.MapOptions.restriction and gmaps.MapRestriction
    // see: https://developers.google.com/maps/documentation/javascript/reference/map#MapOptions.restriction
  }

  if (configuration.zoomControlsEnabled != null) {
    options.zoomControl = configuration.zoomControlsEnabled;
  }

  if (configuration.webGestureHandling != null) {
    options.gestureHandling = configuration.webGestureHandling!.name;
  } else if (configuration.scrollGesturesEnabled == false ||
      configuration.zoomGesturesEnabled == false) {
    // Old behavior
    options.gestureHandling = WebGestureHandling.none.name;
  } else {
    options.gestureHandling = WebGestureHandling.auto.name;
  }

  if (configuration.fortyFiveDegreeImageryEnabled != null) {
    options.rotateControl = configuration.fortyFiveDegreeImageryEnabled;
  }

  // These don't have any configuration entries, but they seem to be off in the
  // native maps.
  options.mapTypeControl = false;
  options.fullscreenControl = false;
  options.streetViewControl = false;

  options.styles = styles;

  options.mapId = configuration.cloudMapId;

  return options;
}

gmaps.MapTypeId _gmapTypeIDForPluginType(MapType type) {
  switch (type) {
    case MapType.satellite:
      return gmaps.MapTypeId.SATELLITE;
    case MapType.terrain:
      return gmaps.MapTypeId.TERRAIN;
    case MapType.hybrid:
      return gmaps.MapTypeId.HYBRID;
    case MapType.normal:
    case MapType.none:
      return gmaps.MapTypeId.ROADMAP;
  }
  // The enum comes from a different package, which could get a new value at
  // any time, so provide a fallback that ensures this won't break when used
  // with a version that contains new values. This is deliberately outside
  // the switch rather than a `default` so that the linter will flag the
  // switch as needing an update.
  // ignore: dead_code
  return gmaps.MapTypeId.ROADMAP;
}

gmaps.MapOptions _applyInitialPosition(
  CameraPosition initialPosition,
  gmaps.MapOptions options,
) {
  // Adjust the initial position, if passed...
  options.zoom = initialPosition.zoom;
  options.center = gmaps.LatLng(
      initialPosition.target.latitude, initialPosition.target.longitude);
  return options;
}

// The keys we'd expect to see in a serialized MapTypeStyle JSON object.
final Set<String> _mapStyleKeys = <String>{
  'elementType',
  'featureType',
  'stylers',
};

// Checks if the passed in Map contains some of the _mapStyleKeys.
bool _isJsonMapStyle(Map<String, Object?> value) {
  return _mapStyleKeys.intersection(value.keys.toSet()).isNotEmpty;
}

// Converts an incoming JSON-encoded Style info, into the correct gmaps array.
List<gmaps.MapTypeStyle> _mapStyles(String? mapStyleJson) {
  List<gmaps.MapTypeStyle> styles = <gmaps.MapTypeStyle>[];
  if (mapStyleJson != null) {
    try {
      styles =
          (json.decode(mapStyleJson, reviver: (Object? key, Object? value) {
        if (value is Map && _isJsonMapStyle(value as Map<String, Object?>)) {
          List<Object?> stylers = <Object?>[];
          if (value['stylers'] != null) {
            stylers = (value['stylers']! as List<Object?>)
                .map<Object?>((Object? e) => e != null ? jsify(e) : null)
                .toList();
          }
          return gmaps.MapTypeStyle()
            ..elementType = value['elementType'] as String?
            ..featureType = value['featureType'] as String?
            ..stylers = stylers;
        }
        return value;
      }) as List<Object?>)
              .where((Object? element) => element != null)
              .cast<gmaps.MapTypeStyle>()
              .toList();
      // .toList calls are required so the JS API understands the underlying data structure.
    } on FormatException catch (e) {
      throw MapStyleException(e.message);
    }
  }
  return styles;
}

gmaps.LatLng _latLngToGmLatLng(LatLng latLng) {
  return gmaps.LatLng(latLng.latitude, latLng.longitude);
}

LatLng _gmLatLngToLatLng(gmaps.LatLng latLng) {
  return LatLng(latLng.lat.toDouble(), latLng.lng.toDouble());
}

LatLngBounds _gmLatLngBoundsTolatLngBounds(gmaps.LatLngBounds latLngBounds) {
  return LatLngBounds(
    southwest: _gmLatLngToLatLng(latLngBounds.southWest),
    northeast: _gmLatLngToLatLng(latLngBounds.northEast),
  );
}

CameraPosition _gmViewportToCameraPosition(gmaps.GMap map) {
  return CameraPosition(
    target: _gmLatLngToLatLng(map.center ?? _nullGmapsLatLng),
    bearing: map.heading?.toDouble() ?? 0,
    tilt: map.tilt?.toDouble() ?? 0,
    zoom: map.zoom?.toDouble() ?? 0,
  );
}

// Convert plugin objects to gmaps.Options objects
// TODO(ditman): Move to their appropriate objects, maybe make them copy constructors?
// Marker.fromMarker(anotherMarker, moreOptions);

gmaps.InfoWindowOptions? _infoWindowOptionsFromMarker(Marker marker) {
  final String markerTitle = marker.infoWindow.title ?? '';
  final String markerSnippet = marker.infoWindow.snippet ?? '';

  // If both the title and snippet of an infowindow are empty, we don't really
  // want an infowindow...
  if ((markerTitle.isEmpty) && (markerSnippet.isEmpty)) {
    return null;
  }

  // Add an outer wrapper to the contents of the infowindow, we need it to listen
  // to click events...
  final HtmlElement container = DivElement()
    ..id = 'gmaps-marker-${marker.markerId.value}-infowindow';

  if (markerTitle.isNotEmpty) {
    final HtmlElement title = HeadingElement.h3()
      ..className = 'infowindow-title'
      ..innerText = markerTitle;
    container.children.add(title);
  }
  if (markerSnippet.isNotEmpty) {
    final HtmlElement snippet = DivElement()
      ..className = 'infowindow-snippet'
      // `sanitizeHtml` is used to clean the (potential) user input from (potential)
      // XSS attacks through the contents of the marker InfoWindow.
      // See: https://pub.dev/documentation/sanitize_html/latest/sanitize_html/sanitizeHtml.html
      // See: b/159137885, b/159598165
      // The NodeTreeSanitizer.trusted just tells setInnerHtml to leave the output
      // of `sanitizeHtml` untouched.
      // ignore: unsafe_html
      ..setInnerHtml(
        sanitizeHtml(markerSnippet),
        treeSanitizer: NodeTreeSanitizer.trusted,
      );
    container.children.add(snippet);
  }

  return gmaps.InfoWindowOptions()
    ..content = container
    ..zIndex = marker.zIndex;
  // TODO(ditman): Compute the pixelOffset of the infoWindow, from the size of the Marker,
  // and the marker.infoWindow.anchor property.
}

// Attempts to extract a [gmaps.Size] from `iconConfig[sizeIndex]`.
gmaps.Size? _gmSizeFromIconConfig(List<Object?> iconConfig, int sizeIndex) {
  gmaps.Size? size;
  if (iconConfig.length >= sizeIndex + 1) {
    final List<Object?>? rawIconSize = iconConfig[sizeIndex] as List<Object?>?;
    if (rawIconSize != null) {
      size = gmaps.Size(
        rawIconSize[0] as num?,
        rawIconSize[1] as num?,
      );
    }
  }
  return size;
}

// Converts a [BitmapDescriptor] into a [gmaps.Icon] that can be used in Markers.
gmaps.Icon? _gmIconFromBitmapDescriptor(BitmapDescriptor bitmapDescriptor) {
  final List<Object?> iconConfig = bitmapDescriptor.toJson() as List<Object?>;

  gmaps.Icon? icon;

  if (iconConfig[0] == 'fromAssetImage') {
    assert(iconConfig.length >= 2);
    // iconConfig[2] contains the DPIs of the screen, but that information is
    // already encoded in the iconConfig[1]
    icon = gmaps.Icon()
      ..url = ui_web.assetManager.getAssetUrl(iconConfig[1]! as String);

    final gmaps.Size? size = _gmSizeFromIconConfig(iconConfig, 3);
    if (size != null) {
      icon
        ..size = size
        ..scaledSize = size;
    }
  } else if (iconConfig[0] == 'fromBytes') {
    // Grab the bytes, and put them into a blob
    final List<int> bytes = iconConfig[1]! as List<int>;
    // Create a Blob from bytes, but let the browser figure out the encoding
    final Blob blob = Blob(<dynamic>[bytes]);
    icon = gmaps.Icon()..url = Url.createObjectUrlFromBlob(blob);

    final gmaps.Size? size = _gmSizeFromIconConfig(iconConfig, 2);
    if (size != null) {
      icon
        ..size = size
        ..scaledSize = size;
    }
  }

  return icon;
}

// Computes the options for a new [gmaps.Marker] from an incoming set of options
// [marker], and the existing marker registered with the map: [currentMarker].
gmaps.MarkerOptions _markerOptionsFromMarker(
  Marker marker,
  gmaps.Marker? currentMarker,
) {
  return gmaps.MarkerOptions()
    ..position = gmaps.LatLng(
      marker.position.latitude,
      marker.position.longitude,
    )
    ..title = sanitizeHtml(marker.infoWindow.title ?? '')
    ..zIndex = marker.zIndex
    ..visible = marker.visible
    ..opacity = marker.alpha
    ..draggable = marker.draggable
    ..icon = _gmIconFromBitmapDescriptor(marker.icon);
  // TODO(ditman): Compute anchor properly, otherwise infowindows attach to the wrong spot.
  // Flat and Rotation are not supported directly on the web.
}

gmaps.CircleOptions _circleOptionsFromCircle(Circle circle) {
  final gmaps.CircleOptions circleOptions = gmaps.CircleOptions()
    ..strokeColor = _getCssColor(circle.strokeColor)
    ..strokeOpacity = _getCssOpacity(circle.strokeColor)
    ..strokeWeight = circle.strokeWidth
    ..fillColor = _getCssColor(circle.fillColor)
    ..fillOpacity = _getCssOpacity(circle.fillColor)
    ..center = gmaps.LatLng(circle.center.latitude, circle.center.longitude)
    ..radius = circle.radius
    ..visible = circle.visible
    ..zIndex = circle.zIndex;
  return circleOptions;
}

gmaps.PolygonOptions _polygonOptionsFromPolygon(
    gmaps.GMap googleMap, Polygon polygon) {
  // Convert all points to GmLatLng
  final List<gmaps.LatLng> path =
      polygon.points.map(_latLngToGmLatLng).toList();

  final bool isClockwisePolygon = _isPolygonClockwise(path);

  final List<List<gmaps.LatLng>> paths = <List<gmaps.LatLng>>[path];

  for (int i = 0; i < polygon.holes.length; i++) {
    final List<LatLng> hole = polygon.holes[i];
    final List<gmaps.LatLng> correctHole = _ensureHoleHasReverseWinding(
      hole,
      isClockwisePolygon,
      holeId: i,
      polygonId: polygon.polygonId,
    );
    paths.add(correctHole);
  }

  return gmaps.PolygonOptions()
    ..paths = paths
    ..strokeColor = _getCssColor(polygon.strokeColor)
    ..strokeOpacity = _getCssOpacity(polygon.strokeColor)
    ..strokeWeight = polygon.strokeWidth
    ..fillColor = _getCssColor(polygon.fillColor)
    ..fillOpacity = _getCssOpacity(polygon.fillColor)
    ..visible = polygon.visible
    ..zIndex = polygon.zIndex
    ..geodesic = polygon.geodesic;
}

List<gmaps.LatLng> _ensureHoleHasReverseWinding(
  List<LatLng> hole,
  bool polyIsClockwise, {
  required int holeId,
  required PolygonId polygonId,
}) {
  List<gmaps.LatLng> holePath = hole.map(_latLngToGmLatLng).toList();
  final bool holeIsClockwise = _isPolygonClockwise(holePath);

  if (holeIsClockwise == polyIsClockwise) {
    holePath = holePath.reversed.toList();
    if (kDebugMode) {
      print('Hole [$holeId] in Polygon [${polygonId.value}] has been reversed.'
          ' Ensure holes in polygons are "wound in the opposite direction to the outer path."'
          ' More info: https://github.com/flutter/flutter/issues/74096');
    }
  }

  return holePath;
}

/// Calculates the direction of a given Polygon
/// based on: https://stackoverflow.com/a/1165943
///
/// returns [true] if clockwise [false] if counterclockwise
///
/// This method expects that the incoming [path] is a `List` of well-formed,
/// non-null [gmaps.LatLng] objects.
///
/// Currently, this method is only called from [_polygonOptionsFromPolygon], and
/// the `path` is a transformed version of [Polygon.points] or each of the
/// [Polygon.holes], guaranteeing that `lat` and `lng` can be accessed with `!`.
bool _isPolygonClockwise(List<gmaps.LatLng> path) {
  double direction = 0.0;
  for (int i = 0; i < path.length; i++) {
    direction = direction +
        ((path[(i + 1) % path.length].lat - path[i].lat) *
            (path[(i + 1) % path.length].lng + path[i].lng));
  }
  return direction >= 0;
}

gmaps.PolylineOptions _polylineOptionsFromPolyline(
    gmaps.GMap googleMap, Polyline polyline) {
  final List<gmaps.LatLng> paths =
      polyline.points.map(_latLngToGmLatLng).toList();

  return gmaps.PolylineOptions()
    ..path = paths
    ..strokeWeight = polyline.width
    ..strokeColor = _getCssColor(polyline.color)
    ..strokeOpacity = _getCssOpacity(polyline.color)
    ..visible = polyline.visible
    ..zIndex = polyline.zIndex
    ..geodesic = polyline.geodesic;
//  this.endCap = Cap.buttCap,
//  this.jointType = JointType.mitered,
//  this.patterns = const <PatternItem>[],
//  this.startCap = Cap.buttCap,
//  this.width = 10,
}

// Translates a [CameraUpdate] into operations on a [gmaps.GMap].
void _applyCameraUpdate(gmaps.GMap map, CameraUpdate update) {
  // Casts [value] to a JSON dictionary (string -> nullable object). [value]
  // must be a non-null JSON dictionary.
  Map<String, Object?> asJsonObject(dynamic value) {
    return (value as Map<Object?, Object?>).cast<String, Object?>();
  }

  // Casts [value] to a JSON list. [value] must be a non-null JSON list.
  List<Object?> asJsonList(dynamic value) {
    return value as List<Object?>;
  }

  final List<dynamic> json = update.toJson() as List<dynamic>;
  switch (json[0]) {
    case 'newCameraPosition':
      final Map<String, Object?> position = asJsonObject(json[1]);
      final List<Object?> latLng = asJsonList(position['target']);
      map.heading = position['bearing'] as num?;
      map.zoom = position['zoom'] as num?;
      map.panTo(
        gmaps.LatLng(latLng[0] as num?, latLng[1] as num?),
      );
      map.tilt = position['tilt'] as num?;
      break;
    case 'newLatLng':
      final List<Object?> latLng = asJsonList(json[1]);
      map.panTo(gmaps.LatLng(latLng[0] as num?, latLng[1] as num?));
      break;
    case 'newLatLngZoom':
      final List<Object?> latLng = asJsonList(json[1]);
      map.zoom = json[2] as num?;
      map.panTo(gmaps.LatLng(latLng[0] as num?, latLng[1] as num?));
      break;
    case 'newLatLngBounds':
      final List<Object?> latLngPair = asJsonList(json[1]);
      final List<Object?> latLng1 = asJsonList(latLngPair[0]);
      final List<Object?> latLng2 = asJsonList(latLngPair[1]);
      final double padding = json[2] as double;
      map.fitBounds(
        gmaps.LatLngBounds(
          gmaps.LatLng(latLng1[0] as num?, latLng1[1] as num?),
          gmaps.LatLng(latLng2[0] as num?, latLng2[1] as num?),
        ),
        padding,
      );
      break;
    case 'scrollBy':
      map.panBy(json[1] as num?, json[2] as num?);
      break;
    case 'zoomBy':
      gmaps.LatLng? focusLatLng;
      final double zoomDelta = json[1] as double? ?? 0;
      // Web only supports integer changes...
      final int newZoomDelta =
          zoomDelta < 0 ? zoomDelta.floor() : zoomDelta.ceil();
      if (json.length == 3) {
        final List<Object?> latLng = asJsonList(json[2]);
        // With focus
        try {
          focusLatLng =
              _pixelToLatLng(map, latLng[0]! as int, latLng[1]! as int);
        } catch (e) {
          // https://github.com/a14n/dart-google-maps/issues/87
          // print('Error computing new focus LatLng. JS Error: ' + e.toString());
        }
      }
      map.zoom = (map.zoom ?? 0) + newZoomDelta;
      if (focusLatLng != null) {
        map.panTo(focusLatLng);
      }
      break;
    case 'zoomIn':
      map.zoom = (map.zoom ?? 0) + 1;
      break;
    case 'zoomOut':
      map.zoom = (map.zoom ?? 0) - 1;
      break;
    case 'zoomTo':
      map.zoom = json[1] as num?;
      break;
    default:
      throw UnimplementedError('Unimplemented CameraMove: ${json[0]}.');
  }
}

// original JS by: Byron Singh (https://stackoverflow.com/a/30541162)
gmaps.LatLng _pixelToLatLng(gmaps.GMap map, int x, int y) {
  final gmaps.LatLngBounds? bounds = map.bounds;
  final gmaps.Projection? projection = map.projection;
  final num? zoom = map.zoom;

  assert(
      bounds != null, 'Map Bounds required to compute LatLng of screen x/y.');
  assert(projection != null,
      'Map Projection required to compute LatLng of screen x/y');
  assert(zoom != null,
      'Current map zoom level required to compute LatLng of screen x/y');

  final gmaps.LatLng ne = bounds!.northEast;
  final gmaps.LatLng sw = bounds.southWest;

  final gmaps.Point topRight = projection!.fromLatLngToPoint!(ne)!;
  final gmaps.Point bottomLeft = projection.fromLatLngToPoint!(sw)!;

  final int scale = 1 << (zoom!.toInt()); // 2 ^ zoom

  final gmaps.Point point =
      gmaps.Point((x / scale) + bottomLeft.x!, (y / scale) + topRight.y!);

  return projection.fromPointToLatLng!(point)!;
}
